返回文章列表

PostgreSQL 统计信息、基数估算、Extended Statistics 与计划稳定性

从业务尾延迟出发,沿统计信息、基数估算、Extended Statistics、参数计划与 pgx 执行模式,建立可诊断、可验证、可回退的计划稳定性方法。

第 7 章:从统计信息到计划稳定性——一条贯穿高性能、高并发与高可用的因果链

技术基线:PostgreSQL 18,兼顾 PostgreSQL 14—18。Go 示例使用 github.com/jackc/pgx/v5pgxpool

1. 本章只讲一件事:优化器先猜“有多少行”,其他都是后果

先看一条多租户订单查询:

SELECT id, amount, created_at
FROM orders
WHERE tenant_id = $1
  AND status = $2
ORDER BY created_at DESC
LIMIT 100;

它在线上可能出现三种看似互不相关的故障:

  • 夜间批量导入后,原本几毫秒的查询突然开始全表扫描;
  • 小租户一直很快,头部租户却在同一 SQL 上慢几个数量级;
  • 同一条连接前几次执行正常,运行一段时间后 P99 突然恶化。

这些现象最终都指向同一个问题:优化器认为某个节点只会输出很少的行,但实际输出了很多行,或者反过来。

PostgreSQL 的计划形成链路可以压缩成下面一条因果链:

真实数据分布
    ↓ ANALYZE 抽样
统计信息
    ↓ 选择率估算
基数估算:预计有多少行
    ↓ 成本比较
扫描方式、Join 顺序、Join 算法、聚合与排序策略
    ↓ 执行
CPU、内存、I/O、临时文件、锁持有时间、连接占用时间
    ↓ 并发放大
连接池排队、重试、尾延迟、复制延迟
    ↓ 故障切换或升级
业务性能 RTO 是否可控

因此,本章不把 ANALYZEpg_stats、Extended Statistics、Prepared Statement、pgx 和 PgBouncer 当成分散知识点,而是沿着一条主线回答五个问题:

  1. 优化器怎样估算行数?
  2. 统计信息为什么会失真?
  3. 单列统计为什么无法表达多列关系?
  4. 即使统计正确,为什么参数化查询仍可能选错计划?
  5. 如何把修复落实到高性能、高并发和高可用的生产闭环?

贯穿全章的判断原则只有一句:

先找到计划树中最早出现的严重基数误差,再修复产生误差的那一层。不要从最慢的顶层节点倒推根因,也不要先用 GUC 或索引把错误计划“压住”。


2. 第一环:基数估算为什么是整个计划的支点

2.1 rows 不是附属信息,而是计划选择的输入

EXPLAIN 中,每个节点都有预计输出行数;在 EXPLAIN ANALYZE 中,还会出现实际输出行数:

Index Scan ... rows=20 ... actual rows=50000 loops=1

这里最重要的不是节点叫 Index Scan 还是 Seq Scan,而是:

预计 20 行,实际 50,000 行

一旦输入行数错了,后续判断会连续失真:

  • 优化器可能认为 Index Scan 只需做少量随机访问,实际却读取大半张表;
  • 优化器可能认为 Nested Loop 外层只有几十行,实际却执行内层索引探测几十万次;
  • 优化器可能低估 Hash Join 或 HashAggregate 的输入,使哈希表分批并落盘;
  • 优化器可能高估结果集,放弃本来很便宜的索引路径;
  • 优化器可能错误判断是否值得启动并行 worker。

所以“有索引为什么不用”“为什么选择 Nested Loop”“为什么排序落盘”常常都不是第一问题。第一问题是:优化器以为会有多少行?

2.2 用 Q-error 衡量估算偏差

可以用 Q-error 表达估算与实际的倍数差:

Q-error = max(actual_rows / estimated_rows,
              estimated_rows / actual_rows)

例如:

estimated rows = 100
actual rows    = 100,000
Q-error        = 1,000

Q-error 不是越接近 1 就一定越快,也没有适用于所有节点的统一告警阈值。但数量级误差通常足以改变扫描方式、Join 顺序和内存策略。

2.3 错误会沿计划树乘法传播

考虑一个 Nested Loop:

外层:预计 10 行,实际 10,000 行
内层:每次预计 1 行,实际 20 行

优化器预想的工作量大约是:

10 × 1 = 10 次内层结果

真实工作量则可能是:

10,000 × 20 = 200,000 次内层结果

顶层节点可能最慢,但它只是承担了下层误差的后果。因此诊断时必须:

  1. 从计划树叶子节点开始;
  2. 比较每个节点的 Plan RowsActual Rows
  3. 结合 loops 计算累计工作量;
  4. 找到第一个出现数量级偏差的节点。

这一步把后续排障分成两类:

  • 扫描节点先错: 优先检查表行数、列统计、相关列、参数可见性;
  • 扫描节点正确,Join 才开始错: 优先检查 Join key 的不同值数、唯一性、Join 前过滤、跨表分布和数据模型。

2.4 贯穿三个工程维度

维度基数估算错误如何演化
高性能读放大、随机 I/O、重复循环、临时文件、规划或执行 CPU 增长
高并发单查询占用连接更久,池等待上升,锁交叠时间变长,重试形成放大
高可用新主库、恢复库或升级实例即使数据正确,也可能因计划失真无法及时承载流量

后面所有知识点都只是为了回答:导致这个基数误差的统计模型在哪里失真?


3. 第二环:统计信息是优化器眼中的“压缩数据模型”

3.1 为什么 PostgreSQL 不直接数真实行数

规划阶段如果为了每条 SQL 扫描整表并统计真实分布,规划成本会远高于查询本身。PostgreSQL 因此让 ANALYZE 对表进行抽样,把真实数据压缩成少量统计信息。

这意味着统计信息具有三个天然属性:

  • 近似: 它不是精确 count(*),连续两次 ANALYZE 可能略有差异;
  • 滞后: DML 改变真实数据后,统计不会逐行同步更新;
  • 有损: 它只保留高频值、直方图、不同值数等摘要,不记录完整分布。

所以,统计问题不只有“有没有 ANALYZE”。还要继续问:

统计是否足够新?
统计目标是否足以表达尖峰和长尾?
单列摘要是否能够表达查询中的联合分布?
规划时是否看得见本次参数?

3.2 三层统计模型

PostgreSQL 与本章相关的统计可以分为三层:

层次典型对象回答的问题
表级pg_class.reltuplesrelpages表大约有多少行、多少页
单列pg_stats某一列的 NULL、不同值、高频值、范围和物理相关度
多列pg_stats_ext同一张表中几列是否相关、组合数是多少、哪些值组合高频

优化器先用表级统计确定输入规模,再用单列或多列统计计算谓词选择率,最后得到各计划节点的预计行数。

3.3 reltuplesrelpages 为什么只能解决“表有多大”

SELECT
    c.oid::regclass AS relation,
    c.reltuples,
    c.relpages,
    pg_relation_size(c.oid) AS heap_bytes,
    pg_total_relation_size(c.oid) AS total_bytes
FROM pg_class AS c
WHERE c.oid = 'public.orders'::regclass;

reltuplesrelpages 在 VACUUM、ANALYZE、部分 DDL 或索引构建时更新,因此是近似值。优化器还会结合当前关系文件大小对旧行数进行缩放。

但文件增长只能告诉优化器“表变大了”,不能告诉它“新数据的 status='chargeback' 从 0.5% 变成了 60%”。

这解释了一个常见事故:

批量导入后 reltuples 看起来并不离谱

团队误以为统计已经可用

旧 MCV 仍把新高频值当作稀有值

查询继续选择索引随机访问

读放大和 P99 同时爆发

3.4 统计新鲜度:先看“变化是否已经超过模型有效期”

SELECT
    relid::regclass AS relation,
    n_live_tup,
    n_mod_since_analyze,
    round(
        100.0 * n_mod_since_analyze / GREATEST(n_live_tup, 1),
        2
    ) AS modified_pct,
    last_analyze,
    last_autoanalyze,
    analyze_count,
    autoanalyze_count
FROM pg_stat_user_tables
ORDER BY modified_pct DESC, n_mod_since_analyze DESC;

这些值是累计统计系统中的估计,不是审计级精确计数。它们用于发现“模型可能已经过期”,不能机械地把某个百分比当作全局阈值。

对普通表,可以用下面的近似公式理解自动 ANALYZE 的触发门槛:

触发修改行数
≈ autovacuum_analyze_threshold
  + autovacuum_analyze_scale_factor × 表行数估计

大表的关键风险是:即使只修改很小比例,也可能彻底改变某个关键值的分布;而默认比例门槛仍可能要求积累大量修改后才触发。

生产中的合理做法不是关闭自动维护,而是按表调整:

ALTER TABLE public.orders SET (
    autovacuum_analyze_threshold = 10000,
    autovacuum_analyze_scale_factor = 0.01
);

这个值只是示例。需要根据表规模、每日变更量、分布变化速度、查询 SLO、维护 I/O 和 worker 容量验证。

3.5 手工 ANALYZE 的正确位置

ANALYZE public.orders;
ANALYZE public.orders (tenant_id, status, created_at);

-- PostgreSQL 16+
ANALYZE (
    VERBOSE,
    BUFFER_USAGE_LIMIT '128MB'
) public.orders;

手工 ANALYZE 最适合放在以下确定性事件之后:

  • 大批量 COPY 或批量 INSERT;
  • 分区装载、交换或大规模回填;
  • 逻辑订阅初始同步或大批 Apply;
  • 大版本升级后补齐未保留统计;
  • 新建 Extended Statistics 之后。

这一步同时连接三个维度:

  • 高性能: 避免旧分布驱动错误计划;
  • 高并发: 避免放量后慢查询占满连接,再与 autoanalyze 争夺 I/O;
  • 高可用: 把“数据已恢复”推进到“实例已具备稳定承载能力”。

但 ANALYZE 本身也会消耗 CPU、内存带宽和读 I/O,因此不应在高峰对所有大表无差别执行高目标分析。


4. 第三环:单列统计怎样变成选择率

现在回到订单查询。假设优化器正在估算:

WHERE status = 'chargeback'

它不会扫描订单表,而是读取 pg_stats

SELECT
    attname,
    null_frac,
    n_distinct,
    most_common_vals,
    most_common_freqs,
    histogram_bounds,
    correlation
FROM pg_stats
WHERE schemaname = 'public'
  AND tablename = 'orders';

4.1 null_frac:先扣掉不可能匹配的 NULL

null_frac 表示 NULL 行的比例。对普通等值谓词:

status = 'chargeback'

NULL 不会匹配;对:

status IS NULL

null_frac 则直接提供基础选择率。

4.2 n_distinct:尾部分布的分母

n_distinct 的含义:

  • 正数:估算的固定不同值个数;
  • 负数:绝对值乘当前表行数,表示不同值数随表规模增长;
  • -1:不同值数大约等于行数,常见于近似唯一列。

例如表有 1,000,000 行,n_distinct=-0.5,优化器会把不同值数理解为约 500,000。

4.3 MCV:让尖峰值脱离“平均值池”

most_common_valsmost_common_freqs 按位置对应。若常量出现在 MCV 中:

selectivity(status = 'chargeback')
≈ 对应的 most_common_freqs

这使优化器可以区分:

paid        → 99%
chargeback  → 0.5%

而不是把两个值都按 1 / n_distinct 处理。

4.4 未命中 MCV 的值如何估算

可用下面的直觉公式理解尾部平均化:

剩余非空概率质量
= 1 - null_frac - Σ(MCV frequencies)

剩余不同值数
≈ n_distinct - MCV 条目数

未知普通值选择率
≈ 剩余非空概率质量 / 剩余不同值数

例如:

null_frac          = 0.10
n_distinct         = 1000
MCV 条目数         = 20
MCV 频率之和       = 0.40

某个值不在 MCV 中时:

选择率 ≈ (1 - 0.10 - 0.40) / (1000 - 20)
       ≈ 0.0005102

若表有 1,000,000 行,则估算约 510 行。

真实实现还会处理采样误差、负 n_distinct、唯一性和类型专用逻辑。这个公式的价值不是复刻源码,而是说明:统计目标太低时,没进入 MCV 的尖峰值会被错误地平均到长尾中。

4.5 直方图:范围谓词的压缩模型

histogram_bounds 在排除 MCV 后,把剩余值划分为近似等频桶。范围查询例如:

WHERE created_at >= now() - interval '1 day'

主要依赖:

  • 范围内 MCV 的概率质量;
  • 常量在直方图桶中的位置;
  • NULL 比例;
  • 类型的比较和插值逻辑。

直方图是近似等频,不是等宽。时间序列在最近一小时突然形成尖峰时,旧直方图即使覆盖了时间上界,也可能严重低估最新数据。

4.6 correlation:描述物理局部性,不描述业务列关系

correlation 衡量某一列的逻辑顺序与堆中物理位置的一致程度,约在 -11 之间。

  • 接近 1-1:沿索引读取较多行时,Heap 访问更接近顺序 I/O;
  • 接近 0:Heap Fetch 更随机,Index Scan 成本更高。

它不是 tenant_idstatus 的业务相关性。两列之间的关系需要 Extended Statistics。

4.7 什么时候提高列级统计目标

当关键尖峰未进入 MCV、直方图过粗或 n_distinct 波动明显时,应优先按列提高,而不是全局提高:

ALTER TABLE public.orders
    ALTER COLUMN tenant_id SET STATISTICS 500;

ALTER TABLE public.orders
    ALTER COLUMN status SET STATISTICS 1000;

ANALYZE public.orders (tenant_id, status);

提高目标通常意味着更大样本、更多 MCV 和更细直方图,同时也增加 ANALYZE 的 CPU、内存和 I/O 成本。

判断是否值得保留,应看完整闭环:

统计目标提高

关键参数的 estimate 更接近 actual

计划或资源使用更合理

P95/P99 在真实并发下改善

ANALYZE 维护成本可接受

如果第一步改善但计划和尾延迟没有变化,或者问题本来是多列相关、跨表 Join、缺索引或锁等待,就不应继续盲目提高目标。


5. 第四环:单列统计的边界——相关列为什么会被错误相乘

订单查询不只过滤 status,还过滤 tenant_id

WHERE tenant_id = 42
  AND status = 'chargeback'

假设:

tenant_id = 42       占全表 5%
status = chargeback  占全表 1%

在缺少多列统计时,优化器通常近似假设两者独立:

s(A AND B) ≈ s(A) × s(B)
           ≈ 5% × 1%
           ≈ 0.05%

但真实业务可能是:几乎所有 chargeback 都来自少数头部租户。对 tenant 42,实际联合选择率可能是 1%,比估算高 20 倍。

这不是单列统计不新鲜,而是单列模型根本没有表达联合分布的能力

5.1 AND 与 OR 的共同根因

无多列统计时:

s(A AND B) ≈ s(A) × s(B)

OR 条件通常遵循容斥直觉:

s(A OR B) ≈ s(A) + s(B) - s(A AND B)

所以只要交集估算错误,AND 和 OR 都会受到影响。互斥、蕴含、高度相关或“不可能组合”都会破坏独立性假设。

5.2 Extended Statistics 的三个工具不是并列名词,而是三个不同问题的答案

统计类型它回答的问题典型场景
dependencies一列是否在统计意义上决定另一列country='JP' AND currency='JPY'
mcv哪些值组合异常高频、低频或几乎不存在tenant_id=42 AND status='chargeback'
ndistinct多列组合到底有多少种GROUP BY tenant_id, status

创建对象:

CREATE STATISTICS orders_tenant_status_stats
    (dependencies, mcv, ndistinct)
ON tenant_id, status
FROM public.orders;

-- CREATE STATISTICS 只创建定义,必须 ANALYZE 才会填充。
ANALYZE public.orders;

查看:

SELECT
    statistics_name,
    attnames,
    kinds,
    n_distinct,
    dependencies,
    most_common_vals,
    most_common_freqs,
    most_common_base_freqs
FROM pg_stats_ext
WHERE schemaname = 'public'
  AND tablename = 'orders';

5.3 dependencies:修复“条件重复计算”

currency 几乎由 country 决定:

WHERE country = 'JP'
  AND currency = 'JPY'

单列独立假设会把两个条件重复折扣。dependencies 可以告诉优化器:第二个条件并没有提供同等程度的额外过滤能力。

它主要适用于列与常量之间的等值或 IN 条件,不是数据库约束,也不会阻止异常数据写入。

5.4 mcv:直接记录常见组合的联合频率

多列 MCV 不只是各列 MCV 的笛卡尔积。它保存实际高频组合及其联合频率,还可表达:

  • 两个单列值都常见,但组合几乎不存在;
  • 某个租户与某个状态组合异常高频;
  • 某些 OR 条件的重叠与独立模型差异很大。

它表达力更强,但统计对象更大、ANALYZE 成本更高,而且仍只能覆盖有限数量的组合。

5.5 ndistinct:修复聚合和上层 Join 的组合数

假设系统有 10,000 个租户和 20 个状态。简单相乘会得到 200,000 个组合,但每个租户可能只出现 3 种状态,真实组合数远小于乘积。

错误组合数会影响:

  • HashAggregate 的内存估算;
  • 排序规模;
  • 聚合结果继续 Join 时的上层基数;
  • 并行与临时文件决策。

ndistinct 正是用于表达这类多列组合基数。

5.6 Extended Statistics 的明确边界

它不是万能修复:

  • 只能描述同一张表内的列或表达式;
  • 当前不能直接修复两张表之间的 Join 选择率;
  • 仍基于抽样,极稀有组合可能漏样;
  • 不能替代索引、约束、分区和正确 SQL;
  • 统计对象过多会增加 ANALYZE 时间和运维复杂度。

因此,不要对所有列做组合爆炸。应从真实慢查询中提取经常共同出现的谓词,再用 EXPLAIN ANALYZE 验证 Q-error 是否改善。

5.7 Join 基数为什么仍可能出错

简单等值 Join 可以用下面的粗略直觉理解:

join rows
≈ left_rows × right_rows
  / max(ndistinct_left, ndistinct_right)

真实实现还会考虑 NULL、唯一性、MCV 和其他条件,但核心不变:

任一侧输入行数错

Join key 的不同值数错

Join 输出继续错

Extended Statistics 可以修复 Join 前某一张表内部的过滤基数,从而间接改善 Join 输入;但它并没有获得跨表联合分布。

跨表问题的根本方案通常是:

  • 修复两侧单表过滤;
  • 建立主键、唯一约束和外键等可证明信息;
  • 调整索引;
  • 分区裁剪;
  • 预聚合、物化或适度反规范化;
  • 对极端参数拆分查询路径。

至此,我们已经解决了“统计模型本身”的问题。但即使统计完全正确,Prepared Statement 仍可能让优化器看不到本次参数。这就是下一环。


6. 第五环:参数可见性决定“正确统计能否被正确使用”

6.1 Literal、Custom Plan 与 Generic Plan

对 Literal:

WHERE status = 'chargeback'

优化器能直接查 MCV 中 chargeback 的频率。

对 Prepared Statement:

PREPARE orders_by_status(text) AS
SELECT id, tenant_id, status, created_at
FROM public.orders
WHERE status = $1;

PostgreSQL 可能使用两类计划:

计划规划时能否看到参数优势风险
Custom Plan可利用本次 MCV、直方图、分区和部分索引每次执行都要规划
Generic Plan不能复用计划,减少规划 CPU参数倾斜时只能采用平均或保守估算

如果 paid 占 99%,chargeback 占 0.5%,两者的最优路径可能完全不同:

paid         → 顺序扫描可能更便宜
chargeback   → 索引或部分索引可能更便宜

Generic Plan 需要让同一个计划服务两个极端参数,因此“计划稳定”可能变成“所有参数共用一个不适合某些参数的折中计划”。

6.2 为什么常见现象是“前几次快,后来变慢”

plan_cache_mode=auto 下,PostgreSQL 通常先用若干次 Custom Plan,比较其平均估算成本与 Generic Plan,再决定是否复用 Generic Plan。

因此可能出现:

同一物理连接前几次执行
    → Custom Plan,能识别头部租户或稀有状态
后续执行
    → 切换到 Generic Plan
    → 参数被平均化
    → 某类参数突然变慢

这不是“第六次必然变慢”的规则,而是一个强烈诊断信号。连接池会让复现更困难,因为每条物理连接都有自己的 Prepared Statement 和计划计数。

6.3 部分索引会放大参数可见性的差异

假设存在:

CREATE INDEX orders_chargeback_idx
ON orders (created_at DESC)
WHERE status = 'chargeback';

Literal 或 Custom Plan 能看到:

status = 'chargeback'

因此可以证明部分索引谓词成立。

Generic Plan 只看到:

status = $1

规划时无法证明 $1 必然等于 chargeback,通常不能使用该部分索引。

这说明参数敏感问题不只是“频率平均化”,还可能直接改变可选路径集合。

6.4 用 plan_cache_mode 做诊断,不做全局信仰

PREPARE orders_by_status(text) AS
SELECT id, tenant_id, status, created_at
FROM public.orders
WHERE status = $1;

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE orders_by_status('chargeback');

SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE orders_by_status('chargeback');

RESET plan_cache_mode;

同时查看当前会话:

SELECT
    name,
    parameter_types,
    generic_plans,
    custom_plans,
    statement
FROM pg_prepared_statements;

诊断时比较的不只是执行时间,还包括:

  • Generic 计划中是否仍显示 $1
  • 估算行数与实际行数;
  • 是否失去部分索引或分区裁剪;
  • Shared Read/Hit Blocks;
  • planning time 与 execution time;
  • 不同参数类别的 P95/P99。

不要全局 force_custom_plan:高 QPS 查询会把 CPU 消耗到重复规划上。也不要全局 force_generic_plan:参数倾斜、部分索引和分区场景可能出现灾难性尾延迟。

更稳妥的局部方式是:

BEGIN;
SET LOCAL plan_cache_mode = force_custom_plan;
-- 只执行已证明确实参数敏感的语句
COMMIT;

6.5 pgx 的正确分层:保持参数化,只对少数查询改变执行模式

pgx 默认常用 QueryExecModeCacheStatement,在每条物理连接上缓存协议级 Prepared Statement。它适合高复用、参数分布相对稳定的 SQL。

对已证明参数敏感的少数查询,可以按查询选择:

rows, err := pool.Query(
    ctx,
    ordersByStatusSQL,
    pgx.QueryExecModeExec,
    status,
)

QueryExecModeExec 仍使用 $1 参数绑定,并不等于拼接 Literal。它避免该语句跨执行使用命名服务端 Prepared Statement,同时保留安全参数化。

一个合理的策略层次是:

默认查询
  → CacheStatement,提高复用效率

已证明参数敏感的查询
  → QueryExecModeExec
  或事务内 SET LOCAL force_custom_plan

不同参数需要完全不同数据路径
  → 拆分业务端点、SQL 或索引策略

错误做法是拼接用户输入:

sql := "SELECT id FROM orders WHERE status = '" + userInput + "'"

它会带来 SQL 注入、转义错误、SQL 文本爆炸和可观测性碎片化,而且无法修复陈旧统计、多列相关、跨表 Join、锁等待或 I/O 饱和。

6.6 PgBouncer:先区分协议状态与会话状态

需要区分:

  1. SQL 文本级 PREPARE name AS ...
  2. PostgreSQL 协议级 Parse/Bind/Execute 的命名 Prepared Statement。

PgBouncer 对协议级 Prepared Statement 的支持取决于版本、池模式与 max_prepared_statements 配置。transaction pooling 下,SQL 文本级 PREPARE、临时表、会话 GUC 和 advisory lock 等会话状态仍必须单独审查。

生产决策不应只写“支持”或“不支持”,而应记录:

PgBouncer 实际版本
pool_mode
max_prepared_statements
pgx DefaultQueryExecMode
DDL 后如何回收缓存连接
Failover 后如何限速重连与重新准备

6.7 Schema 变化为什么会让“自动失效”仍然报错

PostgreSQL 会在依赖对象 DDL、统计更新或 search_path 变化后,使缓存计划在后续使用时重新分析或规划。但客户端缓存的旧结果描述仍可能与新结构不兼容。

尤其是:

SELECT * FROM orders;

新增列或改变返回类型后,旧 Prepared Statement 可能出现结果类型不兼容错误。生产迁移应采用:

  1. 显式列名;
  2. expand/contract;
  3. 先部署兼容应用,再变更结构;
  4. 按实例滚动回收 pgxpool 连接;
  5. 按 PgBouncer 官方方式分批回收后端;
  6. 加抖动和连接速率限制,避免重连与重规划风暴。

到这里,统计、基数和参数可见性已经连成一条完整链。接下来把它转化为一套统一排障方法。


7. 一套统一排障法:从业务尾延迟回溯到最早失真点

7.1 第 0 步:先分清慢在哪里

应用观测必须把一次请求拆成:

请求总耗时
= 连接池等待
+ 数据库规划/执行
+ 网络与结果处理

若请求耗时 5 秒,其中 4.8 秒在等待连接,数据库中的某条慢 SQL 可能是根因,但当前请求本身并没有执行 5 秒。此时盲目增加连接通常会把 CPU、I/O 和锁竞争推入更深的饱和区。

至少同时收集:

  • 应用端总耗时和 pool acquire duration;
  • query ID、参数类别、调用量、P50/P95/P99;
  • 数据库 active queries、wait events、CPU、I/O;
  • 批量导入、ANALYZE、DDL、发布、PgBouncer 变更和 Failover 时间线。

7.2 第 1 步:确认统计是否过期

SELECT
    relid::regclass AS relation,
    n_live_tup,
    n_mod_since_analyze,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE relid = 'public.orders'::regclass;

如果故障紧跟批量导入、回填或逻辑同步,优先检查旧 MCV 和直方图是否仍在描述旧数据。

7.3 第 2 步:拿到代表性计划,而不是只拿一条“平均计划”

至少覆盖:

高频值与稀有值
头部租户与长尾租户
Literal
force_custom_plan
force_generic_plan

建议在可控环境执行:

EXPLAIN (
    ANALYZE,
    BUFFERS,
    SETTINGS,
    VERBOSE,
    SUMMARY
)
SELECT ...;

对 DML,EXPLAIN ANALYZE 会真正执行修改;即使回滚,Sequence、外部调用和某些触发器副作用也未必完全恢复。生产应优先使用只读副本、脱敏克隆、canary 或低风险 SELECT。

7.4 第 3 步:找到第一个严重 Q-error

逐节点记录:

Plan Rows
Actual Rows
Loops
Actual Rows × Loops
Rows Removed by Filter
Shared Hit / Read Blocks
Sort Method / Disk
Hash Batches

定位规则:

第一个失真点更可能的根因
表扫描输入规模就错reltuples、分区入口或统计缺失
单列过滤错统计过旧、MCV/直方图过粗、目标过低
多列过滤错独立性假设,需要 dependencies/mcv
GROUP BY 组合数错需要多列 ndistinct
Custom 正确、Generic 错参数敏感或部分索引/分区谓词不可证明
扫描都准,Join 才错Join key、唯一性、跨表分布、模型与索引
估算准但仍慢I/O、锁、连接排队、真实数据量、成本参数或资源饱和

7.5 第 4 步:只修复产生误差的层

根因首选修复不应先做
统计过旧ANALYZE 目标表/关键列,调整表级 autoanalyze重启数据库
尖峰未进入 MCV提高关键列统计目标后重 ANALYZE全局把目标设到极高
同表列相关建立 dependencies/mcv/ndistinct期待更多普通索引自动修复估算
参数敏感局部 Custom、pgx Exec、拆分参数路径全库 force_custom_plan 或拼 Literal
跨表 Join修输入、约束、索引、分区、预聚合或模型期待 Extended Statistics 跨表生效
资源或锁问题背压、限流、解除 blocker、容量治理把所有慢都归因于统计

7.6 第 5 步:验证必须跨越三个维度

修复不能只以“节点名称变了”为完成标准。至少验证:

估算:estimated vs actual、Q-error
性能:planning time、execution time、Buffers、temp、CPU、I/O
并发:pool acquire、active queries、queue depth、锁与重试
高可用:副本延迟、切换后重连、统计是否就绪、RTO
业务:头部/长尾参数的 P50/P95/P99

并持续覆盖一个完整峰值周期。统计修复可能降低执行 CPU,但提高 ANALYZE 成本;Custom Plan 可能修复头部租户,却增加规划 CPU。只有全链路指标都可接受,才算根本修复。


8. 将同一条因果链放到高性能、高并发和高可用中

8.1 高性能:错误估算如何映射到资源

资源典型因果链
CPUGeneric/Custom 选择不当导致重复规划,或坏计划进行大量无效过滤和 Nested Loop
内存Hash Join/HashAggregate 输入低估,实际分批或多个并发节点累计内存
随机 I/O高频值被当作稀有值,Index Scan 执行大量随机 Heap Fetch
顺序 I/O稀有值被当作高频值,错误选择全表扫描
临时文件Sort、Hash 或 Aggregate 的真实输入远超估算
网络查询返回过多数据,或不合适执行模式增加协议往返
WALANALYZE 本身主要读;但坏 DML 计划触碰更多行会放大 WAL 和同步复制等待

这里有两个重要反直觉:

  1. 正确计划不一定使用索引;返回大比例数据时 Seq Scan 可能更便宜。
  2. 提高 work_mem 不能替代修复基数。work_mem 按节点、worker 和并发查询累积,可能把落盘问题变成内存风险。

8.2 高并发:慢查询首先消耗的是“连接时间”

必须区分:

应用 goroutine 数 ≠ 数据库连接数
数据库连接数       ≠ 活跃查询数
活跃查询数         ≠ TPS
排队请求数         ≠ 锁等待数

假设查询原本占用连接 10 ms,错误计划后占用 5 s。单连接吞吐下降约 500 倍。即使数据库连接数不变,应用侧也会迅速积累等待。

因此高并发治理的顺序通常是:

  1. 有界 MaxConns
  2. HTTP/gRPC deadline;
  3. 在线请求与批任务隔离连接池或角色;
  4. 按租户、报表或任务限流;
  5. 只对明确 SQLSTATE 重试完整事务;
  6. 指数退避、抖动、最大次数和幂等键;
  7. 修复最早基数误差。

盲目增加连接会让每个坏计划同时消耗更多 CPU 和 I/O,形成连接放大。

8.3 高并发中的重规划风暴

统计更新、DDL、连接重建或 Failover 会使大量连接在后续请求中重新规划或重新准备语句。如果所有应用实例同时重连,可能出现:

连接建立峰值
+ Prepared Statement 重建
+ 计划重新生成
+ 缓存冷启动
= 短时 CPU 与延迟尖峰

应采用滚动回收、随机抖动、连接建立限速和 canary,而不是一次性重启全部连接池。

8.4 高可用:数据正确不等于性能就绪

统计信息通常不改变事务复制的 RPO 语义,但会直接影响性能 RTO。

物理复制

物理复制会复制系统目录变化,因此 Standby 通常拥有 Primary 的优化器统计。但:

  • 相同统计不代表相同缓存冷热和设备延迟;
  • Prepared Statement 是后端内存状态,不会复制;
  • Promotion 后应用必须重连并重新准备;
  • 新主库的写入分布变化后,仍要继续维护统计。

逻辑复制

逻辑复制不复制优化器系统目录统计。Subscriber 在初始同步或大批 Apply 后必须本地 ANALYZE,才能安全承担读流量。

升级和恢复

PostgreSQL 18 的 pg_upgrade 可保留大部分优化器统计,降低升级后的性能悬崖,但显式 Extended Statistics 等仍需核对和重建。新版本优化器、硬件环境和缓存状态也可能让计划变化。

因此 HA 就绪检查不能只问:

数据是否追平?

还要问:

关键表统计是否存在且足够新?
扩展统计是否已填充?
头部与长尾参数计划是否回归?
连接和 Prepared Statement 重建是否限速?
切换后 P99、I/O 和 pool acquire 是否在门限内?

8.5 三维决策矩阵

场景高性能策略高并发策略高可用策略
批量导入改变分布ANALYZE 变化列,验证 MCV/计划导入、ANALYZE 与在线流量做 admission controlANALYZE 与 canary 通过后再切流
同表相关列误估Extended Statistics减少错误计划导致的长连接占用在新主库/恢复库验证统计对象与数据
参数敏感查询局部 Custom 或 pgx Exec限制头部参数并发,监控规划 CPUFailover 后重新准备并覆盖冷热参数
在线 DDLexpand/contract、显式列名分批回收连接,避免重规划峰值保留兼容窗口和回滚路径
逻辑副本承担读流量本地 ANALYZE限速同步后 canary把统计就绪纳入切换门禁
大版本升级统计迁移后仍做计划回归分阶段 ANALYZE,避免 I/O 峰值统计缺失检查、回滚与性能 RTO 验证

9. 三个递进实验:沿着同一条链验证三个失真点

只在可丢弃环境执行。节点类型和耗时受硬件、缓存、成本参数和 PostgreSQL 小版本影响。实验成功标准是估算与资源行为的变化,不是强求某个固定节点名称或固定毫秒数。

9.1 实验一:旧统计如何把高频值当成稀有值

目标

验证:文件增长可以修正总行数,却不能更新列分布;批量导入后若不 ANALYZE,查询可能选择高读放大的索引路径。

准备旧分布

DROP SCHEMA IF EXISTS ch07 CASCADE;
CREATE SCHEMA ch07;

CREATE TABLE ch07.bulk_items (
    id       bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    category text NOT NULL,
    payload  text NOT NULL
) WITH (
    -- 只用于实验,故意推迟 autoanalyze。
    autovacuum_analyze_threshold = 100000000,
    autovacuum_analyze_scale_factor = 1.0
);

CREATE INDEX bulk_items_category_idx
ON ch07.bulk_items (category);

INSERT INTO ch07.bulk_items (category, payload)
SELECT 'legacy', repeat(md5(g::text), 3)
FROM generate_series(1, 20000) AS g;

ANALYZE ch07.bulk_items;

批量导入新分布

INSERT INTO ch07.bulk_items (category, payload)
SELECT 'new', repeat(md5(g::text), 3)
FROM generate_series(1, 480000) AS g;

此时 new 占约 96%,但统计仍主要认识 legacy

ANALYZE 前检查

SELECT
    relid::regclass,
    n_live_tup,
    n_mod_since_analyze,
    last_analyze,
    last_autoanalyze
FROM pg_stat_user_tables
WHERE relid = 'ch07.bulk_items'::regclass;

SELECT
    c.reltuples,
    c.relpages,
    pg_relation_size(c.oid) AS heap_bytes
FROM pg_class AS c
WHERE c.oid = 'ch07.bulk_items'::regclass;

SELECT
    attname,
    n_distinct,
    most_common_vals,
    most_common_freqs
FROM pg_stats
WHERE schemaname = 'ch07'
  AND tablename = 'bulk_items';

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT id, category, payload
FROM ch07.bulk_items
WHERE category = 'new';

重点记录:

estimated rows vs actual rows
Index/Bitmap/Seq Scan
Rows Removed by Filter
Shared Hit / Read Blocks

ANALYZE 后重测

-- PostgreSQL 16+
ANALYZE (
    VERBOSE,
    BUFFER_USAGE_LIMIT '64MB'
) ch07.bulk_items;

-- PostgreSQL 14/15 使用普通 ANALYZE。

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT id, category, payload
FROM ch07.bulk_items
WHERE category = 'new';

结论

旧统计
  → new 被当成未知尾部值
  → 高频值被低估
  → 索引随机访问可能被误判为便宜

新统计
  → new 进入 MCV,频率接近真实分布
  → 估算改善
  → 优化器重新比较顺序与索引路径

这一步验证的是“统计新鲜度”。如果 ANALYZE 后两个单列都准确,但组合条件仍错,就进入实验二。

9.2 实验二:单列都准确,联合条件仍被低估

目标

验证独立性假设如何低估相关列,并用 dependenciesmcvndistinct 修复过滤与聚合基数。

准备数据

CREATE TABLE ch07.correlated_events (
    id        bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    region_id integer NOT NULL,
    shard_id  integer NOT NULL,
    payload   text NOT NULL
);

INSERT INTO ch07.correlated_events (region_id, shard_id, payload)
SELECT
    g % 100,
    g % 100,
    repeat(md5(g::text), 2)
FROM generate_series(1, 500000) AS g;

CREATE INDEX correlated_events_region_shard_idx
ON ch07.correlated_events (region_id, shard_id);

ANALYZE ch07.correlated_events;

每个单列值约占 1%,但 region_idshard_id 完全相等。

扩展统计前

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.correlated_events
WHERE region_id = 42
  AND shard_id = 42;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT region_id, shard_id, count(*)
FROM ch07.correlated_events
GROUP BY region_id, shard_id;

理论上:

独立估算:1% × 1% = 0.01%
实际联合:约 1%
过滤 Q-error:约 100

创建扩展统计并重测

CREATE STATISTICS correlated_events_rs_stats
    (dependencies, mcv, ndistinct)
ON region_id, shard_id
FROM ch07.correlated_events;

ANALYZE ch07.correlated_events;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.correlated_events
WHERE region_id = 42
  AND shard_id = 42;

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT region_id, shard_id, count(*)
FROM ch07.correlated_events
GROUP BY region_id, shard_id;

结论

  • 等值过滤的估算应显著接近实际;
  • GROUP BY 组合数应从接近 100 × 100 改善到接近 100;
  • 计划节点可能不变,但 Q-error 改善本身就是成功;
  • 若跨表 Join 仍估错,不能继续堆 Extended Statistics,而应检查 Join 边界。

这一步验证的是“统计表达能力”。如果统计和联合分布都准确,但某些参数仍慢,就进入实验三。

9.3 实验三:正确统计在 Generic Plan 中为什么仍然失效

目标

验证严重倾斜值在 Literal、Custom Plan 和 Generic Plan 下的差异,并观察部分索引对参数可见性的依赖。

准备数据

CREATE TABLE ch07.skew_orders (
    id         bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    status     text NOT NULL,
    created_at timestamptz NOT NULL,
    filler     text NOT NULL
);

INSERT INTO ch07.skew_orders (status, created_at, filler)
SELECT
    CASE WHEN g <= 497500 THEN 'paid' ELSE 'chargeback' END,
    clock_timestamp() - (g % 86400) * interval '1 second',
    repeat(md5(g::text), 4)
FROM generate_series(1, 500000) AS g;

CREATE INDEX skew_orders_chargeback_idx
ON ch07.skew_orders (id)
WHERE status = 'chargeback';

ANALYZE ch07.skew_orders;

Literal 对照

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.skew_orders
WHERE status = 'chargeback';

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
SELECT *
FROM ch07.skew_orders
WHERE status = 'paid';

Custom 与 Generic 对照

PREPARE skew_q(text) AS
SELECT *
FROM ch07.skew_orders
WHERE status = $1;

SET plan_cache_mode = force_custom_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('chargeback');

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('paid');

SET plan_cache_mode = force_generic_plan;
EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('chargeback');

EXPLAIN (ANALYZE, BUFFERS, SETTINGS, VERBOSE, SUMMARY)
EXECUTE skew_q('paid');

SELECT name, generic_plans, custom_plans, statement
FROM pg_prepared_statements
WHERE name = 'skew_q';

RESET plan_cache_mode;
DEALLOCATE skew_q;

结论

  • Literal 与 Custom 能识别参数频率;
  • Generic 计划中通常仍显示 $1
  • Generic 无法证明 $1='chargeback',通常不能使用对应部分索引;
  • 稀有值与高频值共享计划时,至少一类参数可能承担明显的资源放大;
  • 修复应局部作用于该语句,而不是全局改变整个实例的计划策略。

清理

DROP SCHEMA ch07 CASCADE;

三个实验依次证明了三类不同根因:

实验一:模型过期
实验二:模型表达能力不足
实验三:模型正确,但规划时看不到参数

这三者不能用同一个“多跑 ANALYZE”或“强制走索引”解决。


10. Go 与 pgx:把计划策略落实到连接池和请求边界

正文只保留决策骨架,完整可运行示例见配套文件 ch07_pgx_example.go

10.1 默认路径:有界连接池 + 参数化 + Statement Cache

cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
    return nil, fmt.Errorf("parse DATABASE_URL: %w", err)
}

cfg.MaxConns = 16
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
cfg.ConnConfig.DefaultQueryExecMode = pgx.QueryExecModeCacheStatement

pool, err := pgxpool.NewWithConfig(ctx, cfg)

这里的重点不是 16 这个数字,而是:

  • 连接数必须有界;
  • 查询必须受 context deadline 控制;
  • 默认保留参数化与缓存复用;
  • 只有经过证据确认的查询才覆盖默认模式。

10.2 参数敏感查询:按查询使用 QueryExecModeExec

queryCtx, cancel := context.WithTimeout(ctx, 2*time.Second)
defer cancel()

rows, err := pool.Query(
    queryCtx,
    ordersByStatusSQL,
    pgx.QueryExecModeExec,
    status,
)

适合:

  • 高频值与稀有值最优计划差异很大;
  • Generic Plan 已被实验确认是尾延迟根因;
  • 不希望该语句进入命名 Prepared Statement 缓存;
  • 仍要保留安全参数绑定。

10.3 事务内局部强制 Custom Plan

tx, err := pool.BeginTx(ctx, pgx.TxOptions{AccessMode: pgx.ReadOnly})
if err != nil {
    return err
}
defer tx.Rollback(context.Background())

if _, err := tx.Exec(ctx,
    "SET LOCAL plan_cache_mode = force_custom_plan",
); err != nil {
    return err
}

rows, err := tx.Query(ctx, ordersByStatusSQL, status)

它适合少数已证明参数敏感、且希望明确控制服务端计划行为的事务。需要同时监控 planning CPU,避免把执行瓶颈转移成规划瓶颈。

10.4 错误分类与重试边界

只有明确可重试的事务错误,例如 4000140P01,才应在完整事务边界重试,并配合:

  • 指数退避;
  • 随机抖动;
  • 最大次数;
  • context 取消;
  • 业务幂等键。

慢计划、statement timeout、连接重置和提交结果不确定不能无条件重放。错误计划让事务持续更久,会增加死锁和序列化冲突概率,因此重试策略必须与基数治理一起设计。

10.5 DDL 与 Failover 后的连接处理

DDL / Failover

旧物理连接失效或缓存描述过期

滚动回收连接

按实例加抖动重新建立

重新准备高频语句

canary 验证头部/长尾参数

不要一次性重启全部应用池。否则连接建立、TLS、认证、Prepared Statement 和重新规划会在同一时间形成峰值。


11. 生产生命周期中的三个门禁

11.1 批量导入门禁

批量写入提交
  → 校验行数与业务不变量
  → ANALYZE 新分区或变化列
  → 查看 MCV、直方图与 n_mod_since_analyze
  → 对头部/长尾/高频/稀有参数做 EXPLAIN
  → 小流量验证 P95/P99、Buffers、CPU、I/O
  → 正式放量

这比“等 autoanalyze 自己完成”更可控。大表的导入和 ANALYZE 还必须纳入 I/O admission control,避免维护与在线查询同时压满存储。

11.2 在线 DDL 门禁

先扩展兼容结构
  → 部署兼容新旧结构的应用
  → 回填并校验
  → 切换读写
  → 滚动回收连接与语句缓存
  → 删除旧结构

核心要求:显式列名、expand/contract、分批连接回收、可回滚。

11.3 HA 切换门禁

复制追平
  → 统计对象与数据核对
  → 代表查询计划回归
  → 连接重建限速
  → canary
  → 观察 P99、pool acquire、I/O、复制延迟
  → 扩大流量

逻辑副本必须增加“本地 ANALYZE”步骤;物理副本虽有目录统计,也不能跳过缓存冷热与 Prepared Statement 重建验证。


12. 三个事故案例:用同一套因果链复盘

12.1 案例一:夜间导入后,索引扫描拖垮早高峰

现象

risk_level='high' 原本占 0.2%,新批次变成 65%。查询仍走索引,随机 I/O、连接占用和 Replica lag 同时上升。

最早失真点

扫描节点把 high 估算为稀有值。reltuples 已接近新规模,但 MCV 仍是旧分布。

放大链

旧 MCV
  → 高频值低估
  → 大范围 Index Scan
  → 随机 Heap Fetch
  → 查询占用连接变长
  → pool acquire 与队列上升
  → I/O 竞争使副本重放变慢

止损

限流报表和批任务,手工 ANALYZE 关键列,小流量验证后放量。

根治

调整表级 autoanalyze 阈值,把 ANALYZE 与代表参数计划检查加入导入流水线。

12.2 案例二:同一 SQL 前几次正常,头部租户随后变慢

现象

小租户一直正常,头部租户 P99 从几十毫秒升到十几秒;连接存活一段时间后更容易复现。

最早失真点

Custom Plan 能看到头部租户参数,Generic Plan 只按平均租户估算;同时 tenant_id,status 高度相关。

放大链

参数平均化 + 缺多列统计
  → 头部租户严重低估
  → 不合适索引/Nested Loop
  → CPU、随机读、连接占用上升
  → 连接池排队

止损

对该 query ID 使用 pgx QueryExecModeExec 或局部 Custom,并限制头部租户并发。

根治

补充多列 MCV/dependencies,重新设计索引或拆分头部与长尾路径,在同一物理连接覆盖连续多次执行测试。

12.3 案例三:逻辑副本数据追平,却无法承担读流量

现象

分析集群的逻辑订阅已追平,切流后大范围查询和聚合突然落盘,P99 超出目标。

最早失真点

Subscriber 没有本地优化器统计,或新分区和 Extended Statistics 尚未 ANALYZE。

放大链

数据已到位但统计缺失
  → 过滤与 GROUP BY 基数错误
  → 扫描/哈希/排序策略不当
  → 临时文件和 I/O 饱和
  → 切流失败,性能 RTO 延长

止损

停止扩大流量,本地 ANALYZE,完成代表查询回归。

根治

把统计就绪、计划 canary 和连接预热加入逻辑副本切流门禁。


13. 常见反模式:它们为什么看似有效,却切断了因果链

13.1 统计层反模式

  1. 把 ANALYZE 当精确全表统计。 它通常抽样,连续结果可以波动。
  2. 全局把 default_statistics_target 设得极高。 所有表维护变贵,关键查询未必受益。
  3. CREATE STATISTICS 后忘记 ANALYZE。 对象只有定义,没有统计数据。
  4. 期待 Extended Statistics 修复跨表 Join。 它只能描述同表联合分布。
  5. 批量导入后只核对 reltuples 总量接近不代表列分布正确。

13.2 计划层反模式

  1. 有索引就必须走索引。 高频值返回大比例表时 Seq Scan 可能更便宜。
  2. enable_seqscan=off 作为永久修复。 它隐藏了统计、成本或索引设计问题。
  3. 全局强制 Custom Plan。 高 QPS 下重复规划可能成为新瓶颈。
  4. 全局强制 Generic Plan。 倾斜参数、部分索引和分区查询可能出现灾难性 P99。
  5. 拼接 Literal 绕过计划缓存。 引入注入风险和观测碎片,且不能修复统计或 Join 问题。

13.3 并发与高可用反模式

  1. 慢查询后第一动作是增加连接。 错误计划只会被更高并发放大。
  2. 对所有数据库错误无界重试。 会把慢计划放大为重试风暴。
  3. DDL 后同时重启全部应用池。 形成连接与重规划尖峰。
  4. 物理或逻辑复制追平就立即全量切流。 数据一致不等于计划与缓存已经就绪。
  5. 升级后因统计已迁移而跳过计划回归。 扩展统计、优化器版本和硬件状态仍可能导致计划变化。

14. 面试题:沿着“统计 → 基数 → 计划 → 并发 → HA”作答

14.1 统计与基数(1—5)

题 1:ANALYZEVACUUM 有什么区别?

30 秒回答: ANALYZE 抽样生成优化器统计,影响计划;VACUUM 处理死元组、可见性和冻结,维护 MVCC 与空间复用。VACUUM (ANALYZE) 可以一起执行,但目标不同。

深入回答: 统计过旧通常造成性能故障;VACUUM 失控还可能带来 XID wraparound 风险。批量 COPY 后只做 VACUUM 不足以更新分布,仍需 ANALYZE 或确认 autoanalyze 完成。

常见误区: “ANALYZE 是只读版 VACUUM”或“VACUUM 必然更新统计”。

题 2:如何用 pg_stats 解释 col='x' 的选择率?

30 秒回答: 先扣除 null_frac;若 x 在 MCV 中,使用对应频率;否则把剩余非空、非 MCV 的概率质量平均分给剩余不同值。

深入回答: n_distinct 可能为负,需要乘当前行数。提高统计目标的主要价值,是让更多尖峰值进入 MCV、让直方图更细,而不是让统计变成精确全表计数。

常见误区: 所有等值查询都按 1/n_distinct

题 3:n_distinct=-0.5correlation=0.95 各表示什么?

30 秒回答: 前者表示不同值数约为表行数的 50%;后者表示该列顺序与堆物理顺序高度正相关,可能降低大范围 Index Scan 的随机 I/O 成本。

深入回答: correlation 是单列与物理位置的关系,不是两列业务相关性;两列关系需要 Extended Statistics。

常见误区:correlation 解释成两列之间相关 95%。

题 4:为什么 AND 条件容易严重低估?

30 秒回答: 缺少多列统计时,优化器常把不同列近似看作独立,使用 s(A)×s(B);相关、互斥或蕴含关系会破坏这一假设。

深入回答: country='JP'currency='JPY' 可能各占 5%,独立估算得到 0.25%,真实联合可能接近 5%。可用 dependencies 或多列 MCV 修复。

常见误区: 认为 SQL 中条件书写顺序决定从左到右执行。

题 5:dependenciesmcvndistinct 怎么选?

30 秒回答: 等值或 IN 的功能依赖用 dependencies;异常高频或不可能组合用 mcv;多列 GROUP BY 组合数用 ndistinct。

深入回答: 三者可组合,但都基于样本,只适用于同表,不能替代约束和索引,也不能直接修复跨表 Join 选择率。

常见误区: 建一个包含所有列的 mcv 就能解决全部计划问题。

14.2 计划与排障(6—10)

题 6:有索引却走 Seq Scan,首先查什么?

30 秒回答: 先看实际结果比例、行宽和 Buffers,再查统计新鲜度、MCV、直方图、correlation、类型转换和部分索引谓词。Seq Scan 可能本来就是正确计划。

深入回答: 高频值读取大比例表时,索引页加随机 Heap Fetch 可能比顺序扫描更贵。不能用 enable_seqscan=off 代替根因分析。

常见误区: “有索引就必须使用索引”。

题 7:怎样定位 Join Cardinality 错误传播?

30 秒回答: 从计划树底部比较每个节点的 Plan Rows、Actual Rows 和 loops,找到第一个数量级偏差;不要只看顶层最慢节点。

深入回答: 扫描低估会改变 Join 顺序和算法;Nested Loop 外层低估会放大内层循环;Hash 低估会分批和落盘。Extended Statistics 只能先修复单表输入,不能直接表达跨表联合分布。

常见误区: 顶层耗时最大,所以顶层节点就是根因。

题 8:什么时候提高 statistics target?

30 秒回答: 关键列分布不规则、MCV 漏掉尖峰、直方图过粗或 n_distinct 波动时,按列提高并验证,不应全局盲升。

深入回答: 提高后要同时验证 Q-error、计划、P99 和 ANALYZE 成本。如果问题是多列相关、跨表 Join 或缺索引,提高单列目标无效。

常见误区: 统一设置成极大值就一定最好。

题 9:Custom Plan 与 Generic Plan 的权衡是什么?

30 秒回答: Custom 能看本次参数,适合参数敏感查询但有规划 CPU;Generic 可复用计划,适合分布均匀、高频查询,但可能把倾斜参数平均化。

深入回答: Generic 还可能无法证明部分索引谓词或某些分区条件。应通过 force_custom_planforce_generic_plan 对照,再决定局部 pgx Exec、Custom 或拆分路径。

常见误区: Prepared Statement 永远更快,或 Generic 永远更稳定。

题 10:为什么一条查询可能前几次快,后来变慢?

30 秒回答: 同一物理连接在 plan_cache_mode=auto 下可能先使用 Custom,再评估 Generic;切换后倾斜参数失去定制计划,因此变慢。

深入回答: 连接池会让每条物理连接各自经历这一过程。应在同一连接执行连续多次,查看 pg_prepared_statements 计数,并比较 Custom/Generic 的估算与 Buffers。

常见误区: PostgreSQL 每五次固定清理一次计划缓存。

14.3 高性能、高并发与高可用(11—15)

题 11:统计错误如何拖垮连接池?

30 秒回答: 错误计划让单查询占用连接更久,连接吞吐下降,应用请求在池外排队;增加连接会把坏计划同时放大到更多 CPU 和 I/O。

深入回答: 要区分请求总耗时、pool acquire 和数据库执行时间。治理顺序是限流和背压、修复最早 Q-error、再评估连接容量。

常见误区: pool acquire 高就直接扩大 MaxConns

题 12:批量导入后计划异常的 Runbook 是什么?

30 秒回答: 限制流量,核对 n_mod_since_analyze 和旧 MCV,手工 ANALYZE 变化表/列,重跑代表参数 EXPLAIN,再 canary 放量。

深入回答: 文件增长只能缩放总行数,不能更新列分布。大表应把 ANALYZE 和计划验证做成导入门禁,并控制导入、维护和在线查询的 I/O 并发。

常见误区: 等 autovacuum 自己处理,或重启数据库刷新统计。

题 13:pgx + PgBouncer transaction pooling 如何选执行模式?

30 秒回答: 核对 PgBouncer 版本、pool mode 和 max_prepared_statements。兼容环境可保留 CacheStatement;不兼容或参数敏感查询可使用 QueryExecModeExec

深入回答: 协议级 Prepared Statement 与 SQL 文本级 PREPARE 不是一回事。还要测试 DDL、连接回收和 Failover 后的重新准备行为。

常见误区: “PgBouncer 永远不支持 Prepared Statement”或“新版配置无需测试”。

题 14:如何做不破坏缓存计划的在线 Schema 迁移?

30 秒回答: 使用 expand/contract、显式列名和兼容读写顺序;DDL 后分批回收应用与 PgBouncer 连接,不同时重启全部实例。

深入回答: PostgreSQL 会失效依赖计划,但客户端可能保留旧结果描述。SELECT * 在新增列或类型变化时风险更高。完整方案还要有 canary、抖动和回滚。

常见误区: 数据库会自动重规划,所以客户端缓存无需处理。

题 15:为什么统计信息属于 HA 的性能 RTO,而不是事务 RPO?

30 秒回答: 统计通常不改变事务是否复制成功,但会决定新主库、恢复库或逻辑副本能否在切换后稳定执行查询,因此直接影响性能恢复时间。

深入回答: 物理副本通常有目录统计,但没有连接内存中的 Prepared Statement;逻辑副本要本地 ANALYZE;升级后仍要核对 Extended Statistics 和计划变化。

常见误区: 数据追平就等于业务已经可全量切流。


15. 练习与参考答案

15.1 理论练习

练习 1

某表 1,000,000 行:

null_frac = 0.10
n_distinct = 1000
MCV 条目数 = 20
MCV 频率之和 = 0.40

常量 x 不在 MCV 中。估算 col=x 的选择率和行数。

答案:

剩余概率 = 1 - 0.10 - 0.40 = 0.50
剩余不同值 = 1000 - 20 = 980
选择率 ≈ 0.50 / 980 ≈ 0.0005102
估算行数 ≈ 510

练习 2

A、B 各有 100 个均匀值,但总满足 B=A。表有 10,000,000 行。无 Extended Statistics 时,A=7 AND B=7 估算和实际各是多少?

答案:

独立估算:10,000,000 × 1/100 × 1/100 = 1,000
真实结果:10,000,000 × 1/100 = 100,000
Q-error ≈ 100

练习 3

一条查询规划 0.4 ms;长尾参数执行 1 ms,头部参数 Custom 执行 20 ms、Generic 执行 5 s。是否应全局强制 Custom?

答案: 不应。先修统计和索引,再只对该查询或头部参数路径使用 Custom/pgx Exec,并评估调用频率和规划 CPU。全局强制会让所有语句支付重复规划成本。

15.2 实验练习

  1. 将实验三的稀有值比例改为 0.05%、2%、10%,记录 Literal、Custom、Generic 的 Q-error、Buffers 和 P99。
  2. 构造三列强依赖但含 2% 异常组合的数据,分别只建 dependencies、只建 mcv、两者同时建立,比较 AND 与 OR。
  3. 对最近一小时形成尖峰的时间列,比较默认统计目标与目标 1000 的直方图、范围估算和 ANALYZE 成本。

15.3 系统设计练习

设计一个每天导入 20 亿事件的平台,查询包含 tenant_idevent_typecreated_at,要求 15 分钟内可查询、P99 小于 500 ms、头部与长尾租户相差 10,000 倍,并使用 pgxpool、PgBouncer、物理 HA 与逻辑分析副本。

答案至少应包含:

  • 按时间分区,必要时按租户 hash 或头部租户隔离;
  • 关键访问路径索引和受控部分索引;
  • 新分区加载后 ANALYZE;
  • (tenant_id,event_type) 的 mcv/dependencies/ndistinct 验证;
  • 头部与长尾参数分开压测和计划策略;
  • 在线、导入、ANALYZE 使用独立配额;
  • 逻辑副本本地 ANALYZE;
  • 切换时限速重连、canary 和性能 RTO 监控。

16. 生产速查表

16.1 估算直觉

命中 MCV:
  s ≈ 对应频率

未命中 MCV:
  s ≈ (1 - null_frac - Σmcv_freq)
       / (ndistinct - mcv_count)

AND,无多列统计:
  s(A AND B) ≈ s(A) × s(B)

OR:
  s(A OR B) ≈ s(A) + s(B) - s(A AND B)

简单等值 Join:
  rows ≈ left_rows × right_rows
         / max(nd_left, nd_right)

Q-error:
  max(actual/estimate, estimate/actual)

16.2 现象到第一动作

现象第一动作
批量导入后计划异常n_mod_since_analyze、MCV,ANALYZE 变化列
单列尖峰值低估提高该列统计目标并重 ANALYZE
同表相关列 AND 低估CREATE STATISTICS + ANALYZE
GROUP BY 组合数错误多列 ndistinct
稀有与高频参数计划相反比较 Custom/Generic,不拼 Literal
同一连接运行一段时间后变慢查计划缓存启发式与 prepared counters
PgBouncer 下 prepare 异常核对版本、pool mode、配置与 pgx mode
DDL 后 cached plan 错误显式列名,滚动回收连接,expand/contract
扫描准确但 Join 先失真查唯一性、Join key、跨表分布和模型
估算准确但仍慢查锁、I/O、连接排队、真实数据量和成本参数
逻辑副本切流后慢本地 ANALYZE、计划回归、canary

16.3 本章闭环

业务尾延迟
  → 区分排队与数据库执行
  → 对代表参数获取 Literal/Custom/Generic 计划
  → 找到最早 Q-error
  → 判断过期、单列精度、多列相关、参数可见性或 Join 边界
  → 选择最小修复
  → 在性能、并发、HA 三个维度验证
  → 将 ANALYZE、计划回归和连接重建写入生命周期门禁

17. 版本说明与官方资料

17.1 PostgreSQL 14—18 与本章相关的版本提示

版本相关变化
PG14Extended Statistics 对表达式和 OR 条件的利用增强
PG15分区/继承父表统计能力增强
PG16ANALYZE ... BUFFER_USAGE_LIMIT 可控制维护缓冲环
PG17引入 MAINTAIN 权限,维护命令安全性与目录表现有所调整
PG18pg_upgrade 可迁移大部分优化器统计,但仍需核对扩展统计和计划回归

具体行为应以部署版本和小版本官方文档为准。

17.2 官方资料

  1. PostgreSQL 18:Planner Statistics
  2. PostgreSQL 18:pg_stats
  3. PostgreSQL 18:ANALYZE
  4. PostgreSQL 18:CREATE STATISTICS
  5. PostgreSQL 18:Multivariate Statistics Examples
  6. PostgreSQL 18:pg_stats_ext
  7. PostgreSQL 18:PREPARE
  8. PostgreSQL 18:Query Planning / plan_cache_mode
  9. PostgreSQL 18:Auto Analyze 参数
  10. PostgreSQL 18:ANALYZE Progress Reporting
  11. PostgreSQL 18:pg_upgrade
  12. pgx/v5 官方 API
  13. pgxpool 官方 API
  14. PgBouncer:max_prepared_statements

18. 结语

统计信息不是孤立的维护知识,Prepared Statement 也不是孤立的客户端优化。它们共同决定优化器能否对一个具体参数回答:

这一步究竟会有多少行?

一旦这个答案错误,后果会从扫描和 Join 扩散到 CPU、I/O、连接池、锁、复制延迟和故障切换后的性能恢复。

因此,本章最终要建立的不是“遇到慢查询就 ANALYZE”的习惯,而是一套可重复的工程方法:

从业务尾延迟出发,找到计划树中最早的基数失真;判断它来自统计过期、单列精度、多列相关、参数不可见还是跨表边界;只修复对应层,并在高性能、高并发和高可用三个维度完成验证。